iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

之前完成了後端的核心預約 API(BookingService.gs)與取消/改期規章(CancellationService.gs)後,今天我們進入 模組 D:前端互動介面與全日曆整合。

本篇將實作系統的門面——Index.html,透過 HTML5、Tailwind CSS、FullCalendar v6 以及原生 JavaScript,將後端 Apps Script API 與圖形化日曆元件完美對接,為 NGO 職工提供流暢、直覺且響應式的房間預約體驗。


1. 前端架構與設計要點

為確保系統輕量且免除複雜的 Build Process(如 Webpack/Vite),我們採用 CDN 引入 方式,直接在 Google Apps Script 的 HTML 範本中完成前端開發:

  • FullCalendar v6:提供日/週/月檢視,動態載入後端 API 回傳的時段資料。
  • Tailwind CSS (CDN):快速構建 NGO 風格的現代化 UI,支援響應式排版(Mobile-Friendly)。
  • google.script.run:Apps Script 專用的非同步(Asynchronous)RPC 機制,用以呼叫後端的 getAvailableSlots 與 submitNewBooking。

2. 前端介面完整程式碼 (Index.html)

請在 Apps Script 專案中新增名為 Index.html 的 HTML 檔案,並寫入以下程式碼:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>NGO 房間與資源預約系統</title>
  
  <!-- 1. 載入 Tailwind CSS & FullCalendar CDN -->
  <script src="https://cdn.tailwindcss.com"></script>
  <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js'></script>
  
  <style>
    /* 調整 FullCalendar 樣式符合 NGO 簡約風格 */
    .fc-event { cursor: pointer; }
    .fc-toolbar-title { font-size: 1.25rem !important; font-weight: 700; color: #1e293b; }
  </style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans min-h-screen">

  <!-- 頂部導覽列 -->
  <header class="bg-indigo-600 text-white shadow-md py-4 px-6 mb-6 flex justify-between items-center">
    <h1 class="text-xl font-bold flex items-center gap-2">
      🏢 NGO 資源預約平台 <span class="text-xs bg-indigo-500 px-2 py-1 rounded text-indigo-100 font-normal">Day 21 Build</span>
    </h1>
    <div id="userInfo" class="text-sm opacity-90">職工載入中...</div>
  </header>

  <main class="max-w-7xl mx-auto px-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
    
    <!-- 左側:房間選擇與預約表單 -->
    <section class="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
      <h2 class="text-lg font-bold mb-4 text-slate-700 border-b pb-2">➕ 新增房間預約</h2>
      
      <form id="bookingForm" onsubmit="handleFormSubmit(event)" class="space-y-4">
        <!-- 房間選擇 (對應指定 5 個房間) -->
        <div>
          <label class="block text-sm font-medium text-slate-600 mb-1">選擇房間 / 資源</label>
          <select id="roomId" onchange="refreshCalendar()" class="w-full border-slate-300 rounded-lg p-2.5 border focus:ring-2 focus:ring-indigo-500 outline-none">
            <option value="A1">活動室 A1</option>
            <option value="A2">活動室 A2</option>
            <option value="B1">活動室 B1</option>
            <option value="B2">活動室 B2</option>
            <option value="TALK_ROOM">洽談室</option>
          </select>
        </div>

        <!-- 預約日期與時間 -->
        <div>
          <label class="block text-sm font-medium text-slate-600 mb-1">預約日期</label>
          <input type="date" id="bookingDate" required onchange="refreshCalendar()" class="w-full border-slate-300 rounded-lg p-2.5 border focus:ring-2 focus:ring-indigo-500 outline-none">
        </div>

        <div class="grid grid-cols-2 gap-3">
          <div>
            <label class="block text-sm font-medium text-slate-600 mb-1">開始時間</label>
            <input type="time" id="startTime" required class="w-full border-slate-300 rounded-lg p-2 border focus:ring-2 focus:ring-indigo-500 outline-none">
          </div>
          <div>
            <label class="block text-sm font-medium text-slate-600 mb-1">結束時間</label>
            <input type="time" id="endTime" required class="w-full border-slate-300 rounded-lg p-2 border focus:ring-2 focus:ring-indigo-500 outline-none">
          </div>
        </div>

        <!-- 預約用途 (給 Gemini AI 審查) -->
        <div>
          <label class="block text-sm font-medium text-slate-600 mb-1">預約用途說明 (Gemini AI 審核)</label>
          <textarea id="purpose" rows="3" required placeholder="例如:青少年小組面談、個案輔導聚會..." class="w-full border-slate-300 rounded-lg p-2.5 border focus:ring-2 focus:ring-indigo-500 outline-none text-sm"></textarea>
        </div>

        <!-- 提交按鈕與狀態顯示 -->
        <button type="submit" id="submitBtn" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-3 rounded-lg transition-all shadow-sm">
          確認提交預約
        </button>

        <div id="statusAlert" class="hidden p-3 rounded-lg text-sm mt-3"></div>
      </form>
    </section>

    <!-- 右側:FullCalendar 全日曆即時視圖 -->
    <section class="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-slate-200">
      <div id="calendar"></div>
    </section>

  </main>

  <!-- 3. 前端 JavaScript 邏輯與 API 銜接 -->
  <script>
    let calendar;

    // 初始化頁面
    document.addEventListener('DOMContentLoaded', function() {
      // 預設日期設為今天
      const today = new Date().toISOString().split('T')[0];
      document.getElementById('bookingDate').value = today;

      // 初始化 FullCalendar
      const calendarEl = document.getElementById('calendar');
      calendar = new FullCalendar.Calendar(calendarEl, {
        initialView: 'timeGridDay',
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'timeGridDay,timeGridWeek'
        },
        slotMinTime: '08:00:00',
        slotMaxTime: '22:00:00',
        allDaySlot: false,
        height: '650px',
        locale: 'zh-tw'
      });
      calendar.render();

      // 載入當日資料
      refreshCalendar();
    });

    // 呼叫 GAS 後端取得時段資料
    function refreshCalendar() {
      const dateStr = document.getElementById('bookingDate').value;
      const roomId = document.getElementById('roomId').value;

      if (!dateStr || !roomId) return;

      // 呼叫BookingService.gs API
      google.script.run
        .withSuccessHandler(renderCalendarEvents)
        .withFailureHandler(handleApiError)
        .getAvailableSlots(dateStr, roomId);
    }

    // 將後端傳回的 Slots 渲染成 FullCalendar 事件
    function renderCalendarEvents(response) {
      if (!response.success) return;

      calendar.removeAllEvents();

      response.slots.forEach(slot => {
        calendar.addEvent({
          title: slot.isAvailable ? '✅ 可預約' : '❌ 已額滿',
          start: slot.startTimeIso,
          end: slot.endTimeIso,
          backgroundColor: slot.isAvailable ? '#10b981' : '#ef4444',
          borderColor: 'transparent'
        });
      });
    }

    // 提交預約表單處理
    function handleFormSubmit(e) {
      e.preventDefault();
      
      const submitBtn = document.getElementById('submitBtn');
      const statusAlert = document.getElementById('statusAlert');
      
      submitBtn.disabled = true;
      submitBtn.innerText = "⏳ 提交中 & 執行 AI 審查...";
      statusAlert.className = "hidden p-3 rounded-lg text-sm mt-3";

      const dateStr = document.getElementById('bookingDate').value;
      const roomId = document.getElementById('roomId').value;
      const startTime = document.getElementById('startTime').value;
      const endTime = document.getElementById('endTime').value;
      const purpose = document.getElementById('purpose').value;

      const startTimeIso = `${dateStr}T${startTime}:00.000Z`;
      const endTimeIso = `${dateStr}T${endTime}:00.000Z`;

      // 呼叫 Apps Script 後端 submitNewBooking API
      google.script.run
        .withSuccessHandler(onBookingSuccess)
        .withFailureHandler(handleApiError)
        .submitNewBooking("worker@ngo.org", roomId, startTimeIso, endTimeIso, purpose);
    }

    function onBookingSuccess(result) {
      const submitBtn = document.getElementById('submitBtn');
      const statusAlert = document.getElementById('statusAlert');

      submitBtn.disabled = false;
      submitBtn.innerText = "確認提交預約";

      statusAlert.classList.remove('hidden');

      if (result.success) {
        statusAlert.className = "p-3 rounded-lg text-sm mt-3 bg-emerald-50 text-emerald-800 border border-emerald-200";
        statusAlert.innerHTML = `<b>🎉 ${result.message}</b><br>🤖 AI 標註:${result.aiAnalysis}<br>💰 預算金額:HKD $${result.estimatedFee}`;
        refreshCalendar(); // 重新整理日曆
      } else {
        statusAlert.className = "p-3 rounded-lg text-sm mt-3 bg-rose-50 text-rose-800 border border-rose-200";
        statusAlert.innerText = result.message;
      }
    }

    function handleApiError(error) {
      const submitBtn = document.getElementById('submitBtn');
      const statusAlert = document.getElementById('statusAlert');

      submitBtn.disabled = false;
      submitBtn.innerText = "確認提交預約";

      statusAlert.classList.remove('hidden');
      statusAlert.className = "p-3 rounded-lg text-sm mt-3 bg-rose-50 text-rose-800 border border-rose-200";
      statusAlert.innerText = "❌ 發生系統錯誤:" + error.message;
    }
  </script>
</body>
</html>


3. Web App 部署與入口點 (Code.gs)

為使 Index.html 能在瀏覽器中渲染,請確保 Code.gs 中包含以下 HTTP doGet 處理常式:

/**
 * Apps Script Web App 入口點 (HTTP GET)
 */
function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('NGO 房間與資源預約系統')
    .setXframeOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}


4. 系統前端與後端資料串接流程

[使用者於 Index.html 操作]
        │
        ├─► 1. 切換日期/房間 (A1/A2/B1/B2/洽談室) ──► google.script.run.getAvailableSlots()
        │                                              │
        │                                              ▼
        │                                    [後端 BookingService.gs]
        │                                              │
        ├─◄ 2. 渲染綠色/紅色區塊 ◄──────────────────────┘
        │
        └─► 3. 填表提交預約 ─────────────────────────► google.script.run.submitNewBooking()
                                                       │
                                                       ▼
                                             [1. 重疊衝突排查]
                                             [2. Gemini AI 審查]
                                             [3. Telegram 推播]


5. 小結

今天我們完成了 FullCalendar 前端互動介面 (Index.html),精確對接 5 個目標房間(活動室 A1、A2、B1、B2、洽談室),實現了預約狀況視覺化、非同步提交表單、Gemini AI 審核結果實時回傳與 Telegram 警示的閉環整合。


上一篇
【Day 20】取消、改期與 24 小時規章設計與實作
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言